# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer, feature_category: :vulnerability_management do
  let(:identifier) { build(:ci_reports_security_identifier) }

  let_it_be(:project) { create(:project, :repository) }

  let(:location_param) { build(:ci_reports_security_locations_sast, :dynamic) }
  let(:vulnerability_params) do
    vuln_params(project.id, [identifier], confidence: :low, severity: :critical,
      signatures: [create(:vulnerabilities_finding_signature)])
  end

  let(:base_vulnerability) { build(:ci_reports_security_finding, location: location_param, **vulnerability_params) }
  let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability]) }

  let(:head_vulnerability) do
    build(:ci_reports_security_finding, location: location_param, uuid: base_vulnerability.uuid, **vulnerability_params)
  end

  let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability]) }

  shared_context 'for comparing reports' do
    let(:vul_params) { vuln_params(project.id, [identifier]) }
    let(:base_vulnerability) { build(:ci_reports_security_finding, :dynamic, **vul_params) }
    let(:head_vulnerability) { build(:ci_reports_security_finding, :dynamic, **vul_params) }
    let(:head_vul_findings) { [head_vulnerability, vuln] }
  end

  subject(:report_comparer) { described_class.new(project, base_report, head_report) }

  where(vulnerability_finding_signatures: [true, false])

  with_them do
    before do
      stub_licensed_features(vulnerability_finding_signatures: vulnerability_finding_signatures)
    end

    describe '#base_report_out_of_date' do
      context 'with no base report' do
        let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: []) }

        it 'is not out of date' do
          expect(subject.base_report_out_of_date).to be false
        end
      end

      context 'with base report older than one week' do
        let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago - 60.seconds) }
        let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report]) }

        it 'is not out of date' do
          expect(subject.base_report_out_of_date).to be true
        end
      end

      context 'with base report less than one week old' do
        let(:report) { build(:ci_reports_security_report, created_at: 1.week.ago + 60.seconds) }
        let(:base_report) { build(:ci_reports_security_aggregated_reports, reports: [report]) }

        it 'is not out of date' do
          expect(subject.base_report_out_of_date).to be false
        end
      end
    end

    describe '#added' do
      let(:new_location) { build(:ci_reports_security_locations_sast, :dynamic) }
      let(:vul_params) { vuln_params(project.id, [identifier], confidence: :high) }
      let(:vuln) do
        build(:ci_reports_security_finding,
          severity: Enums::Vulnerability.severity_levels[:critical],
          location: new_location,
          **vul_params
        )
      end

      let(:low_vuln) do
        build(:ci_reports_security_finding,
          severity: Enums::Vulnerability.severity_levels[:low],
          location: new_location,
          **vul_params
        )
      end

      context 'with new vulnerability' do
        let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln]) }

        it 'points to source tree' do
          expect(subject.added).to eq([vuln])
        end
      end

      context 'with a dismissed Vulnerability on the default branch' do
        let_it_be(:dismissed_vulnerability) { create(:vulnerability, :dismissed, :with_finding) }
        let(:dismissed_on_default_branch) do
          build(
            :ci_reports_security_finding,
            severity: Enums::Vulnerability.severity_levels[:critical],
            location: new_location,
            uuid: dismissed_vulnerability.finding.uuid,
            **vul_params
          )
        end

        let(:head_report) do
          build(:ci_reports_security_aggregated_reports,
            findings: [dismissed_on_default_branch, vuln, head_vulnerability])
        end

        it 'doesnt report the dismissed Vulnerability' do
          expect(subject.added).not_to include(dismissed_on_default_branch)
          expect(subject.added).to contain_exactly(vuln)
        end
      end

      context 'when comparing reports with different fingerprints' do
        include_context 'for comparing reports'

        let(:head_report) { build(:ci_reports_security_aggregated_reports, findings: head_vul_findings) }

        it 'does not find any overlap' do
          expect(subject.added).to eq(head_vul_findings)
        end
      end

      describe 'order of the findings' do
        let(:head_report) do
          build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln])
        end

        it 'does not change' do
          expect(subject.added).to eq([vuln, low_vuln])
        end
      end

      describe 'number of findings' do
        let(:head_report) do
          build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln])
        end

        before do
          stub_const("#{described_class}::MAX_FINDINGS_COUNT", 1)
        end

        it 'returns no more than `MAX_FINDINGS_COUNT`' do
          expect(subject.added).to eq([vuln])
        end
      end

      describe 'metric tracking' do
        let(:head_report) do
          build(:ci_reports_security_aggregated_reports, findings: [head_vulnerability, vuln, low_vuln])
        end

        it 'measures the execution time of the uuid gathering query' do
          expect(Gitlab::Metrics).to receive(:measure)
                                      .with(described_class::VULNERABILITY_FILTER_METRIC_KEY)
                                      .and_call_original

          subject.added
        end
      end
    end

    describe '#fixed' do
      let(:vul_params) { vuln_params(project.id, [identifier]) }
      let(:vuln) { build(:ci_reports_security_finding, :dynamic, **vul_params) }
      let(:medium_vuln) do
        build(:ci_reports_security_finding, confidence: ::Enums::Vulnerability.confidence_levels[:high],
          severity: Enums::Vulnerability.severity_levels[:medium], uuid: vuln.uuid, **vul_params)
      end

      describe 'metric tracking' do
        let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) }

        it 'measures the execution time of the uuid gathering query' do
          expect(Gitlab::Metrics).to receive(:measure)
                                      .with(described_class::VULNERABILITY_FILTER_METRIC_KEY)
                                      .and_call_original

          subject.fixed
        end
      end

      context 'with a dismissed Vulnerability on the default branch' do
        let_it_be(:dismissed_vulnerability) { create(:vulnerability, :dismissed, :with_finding) }
        let(:new_location) { build(:ci_reports_security_locations_sast, :dynamic) }
        let(:dismissed_on_default_branch) do
          build(
            :ci_reports_security_finding,
            severity: Enums::Vulnerability.severity_levels[:critical],
            location: new_location,
            uuid: dismissed_vulnerability.finding.uuid,
            **vul_params
          )
        end

        let(:base_report) do
          build(:ci_reports_security_aggregated_reports,
            findings: [dismissed_on_default_branch, head_vulnerability, vuln])
        end

        it 'doesnt report the dismissed Vulnerability' do
          expect(subject.fixed).not_to include(dismissed_on_default_branch)
          expect(subject.fixed).to contain_exactly(vuln)
        end
      end

      context 'with fixed vulnerability' do
        let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) }

        it 'points to base tree' do
          expect(subject.fixed).to eq([vuln])
        end
      end

      context 'when comparing reports with different fingerprints' do
        include_context 'for comparing reports'

        let(:base_report) { build(:ci_reports_security_aggregated_reports, findings: [base_vulnerability, vuln]) }

        it 'does not find any overlap' do
          expect(subject.fixed).to eq([base_vulnerability, vuln])
        end
      end

      describe 'order of the findings' do
        let(:vul_findings) { [vuln, medium_vuln] }
        let(:base_report) do
          build(:ci_reports_security_aggregated_reports, findings: [*vul_findings, base_vulnerability])
        end

        it 'does not change' do
          expect(subject.fixed).to eq(vul_findings)
        end
      end

      describe 'number of findings' do
        let(:base_report) do
          build(:ci_reports_security_aggregated_reports, findings: [vuln, medium_vuln, base_vulnerability])
        end

        before do
          stub_const("#{described_class}::MAX_FINDINGS_COUNT", 1)
        end

        it 'returns no more than `MAX_FINDINGS_COUNT`' do
          expect(subject.fixed).to eq([vuln])
        end
      end
    end

    describe 'with empty vulnerabilities' do
      let(:empty_report) { build(:ci_reports_security_aggregated_reports, reports: [], findings: []) }

      it 'returns empty array when reports are not present' do
        comparer = described_class.new(project, empty_report, empty_report)

        expect(comparer.fixed).to eq([])
        expect(comparer.added).to eq([])
      end

      it 'returns added vulnerability when base is empty and head is not empty' do
        comparer = described_class.new(project, empty_report, head_report)

        expect(comparer.fixed).to eq([])
        expect(comparer.added).to eq([head_vulnerability])
      end

      it 'returns fixed vulnerability when head is empty and base is not empty' do
        comparer = described_class.new(project, base_report, empty_report)

        expect(comparer.fixed).to eq([base_vulnerability])
        expect(comparer.added).to eq([])
      end
    end
  end

  describe 'uuids of the findings' do
    let_it_be(:identifier) { create(:vulnerabilities_identifier) }
    let_it_be(:scanner) { create(:vulnerabilities_scanner, project: project) }

    shared_examples_for 'overrides uuids' do
      let(:signature) { build(:vulnerabilities_finding_signature) }

      let(:subject_finding) do
        build(:vulnerabilities_finding,
          identifiers: [identifier],
          signatures: [signature],
          scanner: scanner)
      end

      context 'when the finding matches with an existing one by signatures' do
        let!(:existing_vulnerability_finding) do
          create(:vulnerabilities_finding,
            project: project,
            primary_identifier: identifier,
            scanner: scanner)
        end

        before do
          signature.dup.update!(finding: existing_vulnerability_finding)
        end

        context 'when the `vulnerability_finding_signatures` is disabled' do
          it 'does not override the uuids of the findings' do
            expect { subject_method }.not_to change { subject_finding.uuid }
          end
        end

        context 'when the `vulnerability_finding_signatures` is enabled' do
          before do
            stub_licensed_features(vulnerability_finding_signatures: true)
          end

          it 'overrides the uuids of the findings' do
            expect { subject_method }.to change { subject_finding.uuid }.to(existing_vulnerability_finding.uuid)
          end
        end
      end

      context 'when the finding matches with an existing one by location' do
        let!(:existing_vulnerability_finding) do
          create(:vulnerabilities_finding,
            project: project,
            location_fingerprint: subject_finding.location_fingerprint,
            primary_identifier: identifier,
            scanner: scanner)
        end

        context 'when the `vulnerability_finding_signatures` is disabled' do
          it 'does not override the uuids of the findings' do
            expect { subject_method }.not_to change { subject_finding.uuid }
          end
        end

        context 'when the `vulnerability_finding_signatures` is enabled' do
          before do
            stub_licensed_features(vulnerability_finding_signatures: true)
          end

          it 'overrides the uuids of the findings' do
            expect { subject_method }.to change { subject_finding.uuid }.to(existing_vulnerability_finding.uuid)
          end
        end
      end

      context 'when the finding does not match with an existing one' do
        context 'when the `vulnerability_finding_signatures` is disabled' do
          it 'does not override the uuids of the findings' do
            expect { subject_method }.not_to change { subject_finding.uuid }
          end
        end

        context 'when the `vulnerability_finding_signatures` is enabled' do
          before do
            stub_licensed_features(vulnerability_finding_signatures: true)
          end

          it 'does not override the uuids of the findings' do
            expect { subject_method }.not_to change { subject_finding.uuid }
          end
        end
      end
    end

    describe 'added findings' do
      it_behaves_like 'overrides uuids' do
        let(:base_vulnerability) { build(:vulnerabilities_finding) }
        let(:head_vulnerability) { subject_finding }

        subject(:subject_method) { report_comparer.added }
      end
    end

    describe 'fixed findings' do
      it_behaves_like 'overrides uuids' do
        let(:base_vulnerability) { subject_finding }
        let(:head_vulnerability) { build(:vulnerabilities_finding) }

        subject(:subject_method) { report_comparer.fixed }
      end
    end
  end

  def vuln_params(project_id, identifiers, confidence: :high, severity: :critical, signatures: [])
    {
      project_id: project_id,
      report_type: :sast,
      identifiers: identifiers,
      confidence: ::Enums::Vulnerability.confidence_levels[confidence],
      severity: ::Enums::Vulnerability.severity_levels[severity],
      signatures: signatures
    }
  end
end
